feat: add roots-demo sample for client roots capability#763
feat: add roots-demo sample for client roots capability#763MichielDean wants to merge 9 commits into
Conversation
Closes modelcontextprotocol#5 Add a standalone sample project demonstrating the MCP Roots capability: - Client declares roots capability with listChanged=true - Client registers filesystem roots using addRoot() - Server queries roots from client via listRoots() - Client sends notifications/roots/list_changed after dynamic changes - Server reacts to change notifications by re-fetching the root list Uses ChannelTransport from kotlin-sdk-testing for in-memory client-server communication without external dependencies.
There was a problem hiding this comment.
Pull request overview
Adds a new standalone sample project under samples/roots-demo/ to demonstrate the MCP Roots client capability end-to-end (client advertises roots + list change support, server lists roots, client notifies changes, server re-fetches).
Changes:
- Added a new
roots-demoGradle sample project (wrapper, version catalog, build config, logging config). - Implemented an in-memory client/server demo using
ChannelTransportthat exercisesroots/listandnotifications/roots/list_changed. - Updated
samples/README.mdto list and describe the new sample.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| samples/roots-demo/src/main/resources/simplelogger.properties | Configures SLF4J SimpleLogger output for the demo. |
| samples/roots-demo/src/main/kotlin/io/modelcontextprotocol/sample/roots/Main.kt | Implements the in-memory client/server roots lifecycle demo. |
| samples/roots-demo/settings.gradle.kts | Declares sample project name, repositories, and optional MCP Kotlin version override. |
| samples/roots-demo/README.md | Documents what the sample demonstrates and how to run it. |
| samples/roots-demo/gradlew.bat | Adds Gradle wrapper Windows script for the standalone sample. |
| samples/roots-demo/gradlew | Adds Gradle wrapper POSIX script for the standalone sample. |
| samples/roots-demo/gradle/wrapper/gradle-wrapper.properties | Configures the Gradle distribution for the sample wrapper. |
| samples/roots-demo/gradle/libs.versions.toml | Declares dependency/plugin versions used by the sample. |
| samples/roots-demo/gradle.properties | Enables Gradle performance features and supports optional SDK override version. |
| samples/roots-demo/build.gradle.kts | Configures application plugin, dependencies, and toolchain for the sample. |
| samples/README.md | Adds the new roots demo to the sample index and provides a short description. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Remove Tools capability from server (not used in this demo)
- Use async instead of launch+CompletableDeferred in notification handler
so the returned Deferred represents actual work completion
- Construct file URIs from System.getProperty('user.home') for
cross-platform correctness (also ensures file:/// prefix)
- Apply null-safe name fallback consistently (?: '(unnamed)')
…c sync - Add try/catch in notification handler to surface errors instead of silently dropping them - Replace delay(500) with CompletableDeferred-based synchronization so the demo waits for the server to process each notification deterministically instead of relying on timing - Close server before client to avoid Not connected errors
…-demo The notification handler launches coroutines via on Dispatchers.Default, so the increment was a data race — two coroutines could read the same value and both write back 1, causing rootsUpdated to never complete. Replaced with AtomicInteger.incrementAndGet() for thread-safe counting.
- Await each notification separately (firstNotification/secondNotification) before sending the next, so the server processes each state change before the client sends the next one - Remove withTimeout(5000) that could throw TimeoutCancellationException and skip close() calls; now the main coroutine awaits each signal directly, so shutdown always runs - Keep AtomicInteger for thread-safe notification counting
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
samples/roots-demo/src/main/kotlin/io/modelcontextprotocol/sample/roots/Main.kt:101
firstNotification.await()/secondNotification.await()have no timeout, so./gradlew runcan hang indefinitely if a notification is missed (or if the handler fails before completing the deferred). Consider adding a bounded wait (e.g.,withTimeout) and ensuring shutdown still happens viatry/finallyso the sample exits cleanly in failure cases.
client.sendRootsListChanged()
firstNotification.await()
println("\n[Client] Removing a root and sending list changed notification...")
client.removeRoot(backendRoot)
client.sendRootsListChanged()
secondNotification.await()
…s on errors Move firstNotification/secondNotification completion into a finally block so that even if listRoots() throws, the main coroutine does not hang waiting on a CompletableDeferred that will never complete.
Every await() on a CompletableDeferred needs an escape hatch so the demo terminates even if notifications never arrive. Both notification awaits now use withTimeoutOrNull with a 5s timeout and print a clear message on timeout. Client/server close() moved into a finally block so resources are always released, even on timeout or unexpected errors.
Strip back the over-engineered async coordination (AtomicInteger, per-notification CompletableDeferred, withTimeoutOrNull, try/finally) and replace with a clear, linear flow that demonstrates the Roots lifecycle: declare capability, add roots, query, notify, react. The demo now matches the simplicity of other SDK samples and the integration tests. A short delay is used for notification propagation which is appropriate for sample code — it prioritizes readability over production-grade async patterns. Key changes: - Removed AtomicInteger counter and separate CompletableDeferred signals - Removed withTimeoutOrNull (not needed for a demo) - Removed try/finally on close (not needed — runBlocking handles cleanup) - Restored hardcoded file:// URIs (matches MCP spec examples and other SDK samples) - Added clear comments for each step in the Roots lifecycle - Total: 82 lines, down from 108
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
samples/roots-demo/src/main/kotlin/io/modelcontextprotocol/sample/roots/Main.kt:79
- The
delay(500)shutdown wait is timing-dependent and can be flaky on slower/loaded machines (the notification-triggeredlistRoots()may not finish before close). Prefer deterministic synchronization (e.g., await aCompletableDeferredcompleted by the notification handler after the re-fetch completes) instead of a fixed sleep.
// Client adds a root and notifies the server
client.addRoot("file:///home/user/projects/shared", "Shared Libraries")
client.sendRootsListChanged()
// Allow the notification handler to process before shutting down
delay(500)
| session.setNotificationHandler<RootsListChangedNotification>( | ||
| Method.Defined.NotificationsRootsListChanged, | ||
| ) { | ||
| launch { | ||
| val updatedRoots = session.listRoots() | ||
| println("\n[Server] Roots list changed — updated roots:") | ||
| updatedRoots.roots.forEach { root -> | ||
| println(" ${root.name ?: "(unnamed)"}: ${root.uri}") | ||
| } | ||
| } | ||
| CompletableDeferred(Unit) |
| // Register roots on the client | ||
| client.addRoot("file:///home/user/projects/my-project", "My Project") | ||
| client.addRoot("file:///home/user/Documents", "Documents") | ||
|
|
| 3. **Server queries roots** — `serverSession.listRoots()` sends a `roots/list` | ||
| request to the client. | ||
| 4. **Client sends change notification** — after dynamically adding or removing roots, | ||
| the client calls `sendRootsListChanged()` so the server knows to re-fetch. | ||
| 5. **Server reacts to changes** — the server listens for |
Summary
Closes #5
Adds a standalone sample project (
samples/roots-demo/) demonstrating the MCP Roots capability — how a client exposes filesystem roots to a server, and how the server reacts when the root list changes.What it demonstrates
listChanged = trueduring initializationaddRoot(uri, name)serverSession.listRoots()(roots/listrequest)sendRootsListChanged()after dynamically adding/removing rootsnotifications/roots/list_changedand re-fetching the updated root listImplementation
Uses
ChannelTransportfromkotlin-sdk-testingfor in-memory client-server communication — no external server, network, or API key required. The sample runs as a singlemain()that prints the full roots exchange lifecycle to the console.Files added
samples/roots-demo/— complete standalone Gradle project (build config, version catalog, wrapper)samples/roots-demo/src/main/kotlin/.../Main.kt— demo sourcesamples/roots-demo/README.md— documentationsamples/README.md— updated to include the new sampleBuild verification
./gradlew assemblesucceeds./gradlew runproduces the expected output showing roots lifecyclektlintCheckon the main project passes (sample projects don't include ktlint)